Introduce SortableEvents for user-defined events sorting/reordering

Akinori MUSHA 9 years ago
parent
commit
f86c9c5027
3 changed files with 419 additions and 0 deletions
  1. 154 0
      app/concerns/sortable_events.rb
  2. 1 0
      app/models/agent.rb
  3. 264 0
      spec/concerns/sortable_events_spec.rb

+ 154 - 0
app/concerns/sortable_events.rb

@@ -0,0 +1,154 @@
1
+module SortableEvents
2
+  extend ActiveSupport::Concern
3
+
4
+  included do
5
+    validate :validate_events_order
6
+  end
7
+
8
+  def description_events_order(events = 'events created in each run')
9
+    <<-MD.lstrip
10
+        To specify the order of #{events}, set `events_order` to an array of sort keys, each of which looks like either `expression` or `[expression, type, descending]`, as described as follows:
11
+
12
+        * _expression_ is a Liquid template to generate a string to be used as sort key.
13
+
14
+        * _type_ (optional) is one of `string` (default), `number` and `time`, which specifies how to evaluate _expression_ for comparison.
15
+
16
+        * _descending_ (optional) is a boolean value to determine if comparison should be done in descending (reverse) order, which defaults to `false`.
17
+
18
+        Sort keys listed eariler take precedence over ones listed later.  For example, if you want to sort articles by the date and then by the author, specify `[["{{date}}", "time"], "{{author}}"]`.
19
+
20
+        Sorting is done stably, so even if all events have the same set of sort key values the original order is retained.  Also, a special Liquid variable `_index_` is provided, which contains the zero-based index number of each event, which means you can exactly reverse the order of events by specifying `[["{{_index_}}", "number", true]]`.
21
+    MD
22
+  end
23
+
24
+  module ClassMethods
25
+    def can_order_created_events!
26
+      raise if cannot_create_events?
27
+      prepend AutomaticSorter
28
+    end
29
+
30
+    def can_order_created_events?
31
+      include? AutomaticSorter
32
+    end
33
+
34
+    def cannot_order_created_events?
35
+      !can_order_created_events?
36
+    end
37
+  end
38
+
39
+  def can_order_created_events?
40
+    self.class.__send__(__callee__)
41
+  end
42
+
43
+  def cannot_order_created_events?
44
+    self.class.__send__(__callee__)
45
+  end
46
+
47
+  def events_order
48
+    options['events_order']
49
+  end
50
+
51
+  module AutomaticSorter
52
+    def check
53
+      return super unless events_order
54
+      sorting_events do
55
+        super
56
+      end
57
+    end
58
+
59
+    def receive(incoming_events)
60
+      return super unless events_order
61
+      # incoming events should be processed sequentially
62
+      incoming_events.each do |event|
63
+        sorting_events do
64
+          super([event])
65
+        end
66
+      end
67
+    end
68
+
69
+    def create_event(attrs)
70
+      if @sortable_events
71
+        @sortable_events << events.build({ user: user }.merge(attrs))
72
+      else
73
+        super
74
+      end
75
+    end
76
+
77
+    private
78
+
79
+    def sorting_events(&block)
80
+      @sortable_events = []
81
+      yield
82
+    ensure
83
+      events, @sortable_events = @sortable_events, nil
84
+      sort_events(events).each do |event|
85
+        event.expires_at ||= new_event_expiration_date
86
+        event.save!
87
+      end
88
+    end
89
+  end
90
+
91
+  private
92
+
93
+  EXPRESSION_PARSER = {
94
+    'string' => ->string { string },
95
+    'number' => ->string { string.to_f },
96
+    'time'   => ->string { Time.zone.parse(string) },
97
+  }
98
+  EXPRESSION_TYPES = EXPRESSION_PARSER.keys.freeze
99
+
100
+  def validate_events_order
101
+    case order_by = events_order()
102
+    when nil
103
+    when Array
104
+      # Each tuple may be either [expression, type, desc] or just
105
+      # expression.
106
+      order_by.each do |expression, type, desc|
107
+        case expression
108
+        when String
109
+          # ok
110
+        else
111
+          errors.add(:base, "first element of each events_order tuple must be a Liquid template")
112
+          break
113
+        end
114
+        case type
115
+        when nil, *EXPRESSION_TYPES
116
+          # ok
117
+        else
118
+          errors.add(:base, "second element of each events_order tuple must be #{EXPRESSION_TYPES.to_sentence(last_word_connector: ' or ')}")
119
+          break
120
+        end
121
+        if !desc.nil? && boolify(desc).nil?
122
+          errors.add(:base, "third element of each events_order tuple must be a boolean value")
123
+          break
124
+        end
125
+      end
126
+    else
127
+      errors.add(:base, "events_order must be an array of arrays")
128
+    end
129
+  end
130
+
131
+  # Sort given events in order specified by the "events_order" option
132
+  def sort_events(events)
133
+    order_by = events_order().presence or return events
134
+    orders = order_by.map { |_, _, desc = false| boolify(desc) }
135
+
136
+    Utils.sort_tuples!(
137
+      events.map.with_index { |event, index|
138
+        interpolate_with(event) {
139
+          interpolation_context['_index_'] = index
140
+          order_by.map { |expression, type, _|
141
+            string = interpolate_string(expression)
142
+            begin
143
+              EXPRESSION_PARSER[type || 'string'.freeze][string]
144
+            rescue
145
+              error "Cannot parse #{string.inspect} as #{type}; treating it as string"
146
+              string
147
+            end
148
+          }
149
+        } << index << event  # index is to make sorting stable
150
+      },
151
+      orders
152
+    ).collect!(&:last)
153
+  end
154
+end

+ 1 - 0
app/models/agent.rb

@@ -13,6 +13,7 @@ class Agent < ActiveRecord::Base
13 13
   include HasGuid
14 14
   include LiquidDroppable
15 15
   include DryRunnable
16
+  include SortableEvents
16 17
 
17 18
   markdown_class_attributes :description, :event_description
18 19
 

+ 264 - 0
spec/concerns/sortable_events_spec.rb

@@ -0,0 +1,264 @@
1
+require 'spec_helper'
2
+
3
+describe SortableEvents do
4
+  let(:agent_class) {
5
+    Class.new(Agent) do
6
+      include SortableEvents
7
+
8
+      default_schedule 'never'
9
+
10
+      def self.valid_type?(name)
11
+        true
12
+      end
13
+    end
14
+  }
15
+
16
+  def new_agent(events_order = nil)
17
+    options = {}
18
+    options['events_order'] = events_order if events_order
19
+    agent_class.new(name: 'test', options: options) { |agent|
20
+      agent.user = users(:bob)
21
+    }
22
+  end
23
+
24
+  describe 'validations' do
25
+    let(:agent_class) {
26
+      Class.new(Agent) do
27
+        include SortableEvents
28
+
29
+        default_schedule 'never'
30
+
31
+        def self.valid_type?(name)
32
+          true
33
+        end
34
+      end
35
+    }
36
+
37
+    def new_agent(events_order = nil)
38
+      options = {}
39
+      options['events_order'] = events_order if events_order
40
+      agent_class.new(name: 'test', options: options) { |agent|
41
+        agent.user = users(:bob)
42
+      }
43
+    end
44
+
45
+    it 'should allow events_order to be unspecified, null or an empty array' do
46
+      expect(new_agent()).to be_valid
47
+      expect(new_agent(nil)).to be_valid
48
+      expect(new_agent([])).to be_valid
49
+    end
50
+
51
+    it 'should not allow events_order to be a non-array object' do
52
+      agent = new_agent(0)
53
+      expect(agent).not_to be_valid
54
+      expect(agent.errors[:base]).to include(/events_order/)
55
+
56
+      agent = new_agent('')
57
+      expect(agent).not_to be_valid
58
+      expect(agent.errors[:base]).to include(/events_order/)
59
+
60
+      agent = new_agent({})
61
+      expect(agent).not_to be_valid
62
+      expect(agent.errors[:base]).to include(/events_order/)
63
+    end
64
+
65
+    it 'should not allow events_order to be an array containing unexpected objects' do
66
+      agent = new_agent(['{{key}}', 1])
67
+      expect(agent).not_to be_valid
68
+      expect(agent.errors[:base]).to include(/events_order/)
69
+
70
+      agent = new_agent(['{{key1}}', ['{{key2}}', 'unknown']])
71
+      expect(agent).not_to be_valid
72
+      expect(agent.errors[:base]).to include(/events_order/)
73
+    end
74
+
75
+    it 'should allow events_order to be an array containing strings and valid tuples' do
76
+      agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number']])
77
+      expect(agent).to be_valid
78
+
79
+      agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number'], ['{{key4}}', 'time', true]])
80
+      expect(agent).to be_valid
81
+    end
82
+  end
83
+
84
+  describe 'sort_events' do
85
+    let(:payloads) {
86
+      [
87
+        { 'title' => 'TitleA', 'score' => 4,  'updated_on' => '7 Jul 2015' },
88
+        { 'title' => 'TitleB', 'score' => 2,  'updated_on' => '25 Jun 2014' },
89
+        { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' },
90
+        { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' },
91
+      ]
92
+    }
93
+
94
+    let(:events) {
95
+      payloads.map { |payload| Event.new(payload: payload) }
96
+    }
97
+
98
+    it 'should sort events by a given key' do
99
+      agent = new_agent(['{{title}}'])
100
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleC TitleD])
101
+
102
+      agent = new_agent([['{{title}}', 'string', true]])
103
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleD TitleC TitleB TitleA])
104
+    end
105
+
106
+    it 'should sort events by multiple keys' do
107
+      agent = new_agent([['{{score}}', 'number'], '{{title}}'])
108
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleC TitleD])
109
+
110
+      agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
111
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC])
112
+    end
113
+
114
+    it 'should sort events by time' do
115
+      agent = new_agent([['{{updated_on}}', 'time']])
116
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleD TitleC TitleA])
117
+    end
118
+
119
+    it 'should sort events stably' do
120
+      agent = new_agent(['<constant>'])
121
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
122
+
123
+      agent = new_agent([['<constant>', 'string', true]])
124
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
125
+    end
126
+
127
+    it 'should support _index_' do
128
+      agent = new_agent([['{{_index_}}', 'number', true]])
129
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleC TitleD TitleB TitleA])
130
+    end
131
+  end
132
+
133
+  describe 'automatic event sorter' do
134
+    describe 'declaration' do
135
+      let(:passive_agent_class) {
136
+        Class.new(Agent) do
137
+          include SortableEvents
138
+
139
+          cannot_create_events!
140
+        end
141
+      }
142
+
143
+      let(:active_agent_class) {
144
+        Class.new(Agent) do
145
+          include SortableEvents
146
+        end
147
+      }
148
+
149
+      describe 'can_order_created_events!' do
150
+        it 'should refuse to work if called from an Agent that cannot create events' do
151
+          expect {
152
+            passive_agent_class.class_eval do
153
+              can_order_created_events!
154
+            end
155
+          }.to raise_error
156
+        end
157
+
158
+        it 'should work if called from an Agent that can create events' do
159
+          expect {
160
+            active_agent_class.class_eval do
161
+              can_order_created_events!
162
+            end
163
+          }.not_to raise_error
164
+        end
165
+      end
166
+
167
+      describe 'can_order_created_events?' do
168
+        it 'should return false unless an Agent declares can_order_created_events!' do
169
+          expect(active_agent_class.can_order_created_events?).to eq(false)
170
+          expect(active_agent_class.new.can_order_created_events?).to eq(false)
171
+        end
172
+
173
+        it 'should return true if an Agent declares can_order_created_events!' do
174
+          active_agent_class.class_eval do
175
+            can_order_created_events!
176
+          end
177
+
178
+          expect(active_agent_class.can_order_created_events?).to eq(true)
179
+          expect(active_agent_class.new.can_order_created_events?).to eq(true)
180
+        end
181
+      end
182
+    end
183
+
184
+    describe 'behavior' do
185
+      class Agents::EventOrderableAgent < Agent
186
+        include SortableEvents
187
+
188
+        default_schedule 'never'
189
+
190
+        can_order_created_events!
191
+
192
+        attr_accessor :payloads_to_emit
193
+
194
+        def self.valid_type?(name)
195
+          true
196
+        end
197
+
198
+        def check
199
+          payloads_to_emit.each do |payload|
200
+            create_event payload: payload
201
+          end
202
+        end
203
+
204
+        def receive(events)
205
+          events.each do |event|
206
+            payloads_to_emit.each do |payload|
207
+              create_event payload: payload.merge('title' => payload['title'] + event.payload['title_suffix'])
208
+            end
209
+          end
210
+        end
211
+      end
212
+
213
+      def new_agent(events_order = nil)
214
+        options = {}
215
+        options['events_order'] = events_order if events_order
216
+        Agents::EventOrderableAgent.new(name: 'test', options: options) { |agent|
217
+          agent.user = users(:bob)
218
+          agent.payloads_to_emit = payloads
219
+        }
220
+      end
221
+
222
+      let(:payloads) {
223
+        [
224
+          { 'title' => 'TitleA', 'score' => 4,  'updated_on' => '7 Jul 2015' },
225
+          { 'title' => 'TitleB', 'score' => 2,  'updated_on' => '25 Jun 2014' },
226
+          { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' },
227
+          { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' },
228
+        ]
229
+      }
230
+
231
+      it 'should keep the order of created events unless events_order is specified' do
232
+        [[], [nil], [[]]].each do |args|
233
+          agent = new_agent(*args)
234
+          agent.save!
235
+          expect { agent.check }.to change { Event.count }.by(4)
236
+          events = agent.events.last(4).sort_by(&:id)
237
+          expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
238
+        end
239
+      end
240
+
241
+      it 'should sort events created in check() in the order specified in events_order' do
242
+        agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
243
+        agent.save!
244
+        expect { agent.check }.to change { Event.count }.by(4)
245
+        events = agent.events.last(4).sort_by(&:id)
246
+        expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC])
247
+      end
248
+
249
+      it 'should sort events created in receive() in the order specified in events_order' do
250
+        agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
251
+        agent.save!
252
+        expect {
253
+          agent.receive([Event.new(payload: { 'title_suffix' => ' [new]' }),
254
+                         Event.new(payload: { 'title_suffix' => ' [popular]' })])
255
+        }.to change { Event.count }.by(8)
256
+        events = agent.events.last(8).sort_by(&:id)
257
+        expect(events.map { |event| event.payload['title'] }).to eq([
258
+          'TitleB [new]',     'TitleA [new]',     'TitleD [new]',     'TitleC [new]',
259
+          'TitleB [popular]', 'TitleA [popular]', 'TitleD [popular]', 'TitleC [popular]',
260
+        ])
261
+      end
262
+    end
263
+  end
264
+end